﻿/////////////////////////////////////////////////////////////////////////////////////////////////////
// Copyright (c) 2009 Eric Bowen
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 
// associated documentation files (the "Software"), to deal in the Software without restriction, including 
// without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 
// copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the 
// following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or substantial 
// portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 
// LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN 
// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 
// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
/////////////////////////////////////////////////////////////////////////////////////////////////////
// Author's blog: http://blog.scrappydog.com
// Updated source code available:  http://scrappydb.codeplex.com
/////////////////////////////////////////////////////////////////////////////////////////////////////

using System;
using System.Collections.Specialized;
using System.Configuration;
using System.Configuration.Provider;
using System.Security.Cryptography;
using System.Text;
using System.Web;
using System.Web.Caching;
using System.Web.Configuration;
using System.Web.Hosting;
using System.Web.Security;
using NLog;
using ScrappyDB.BaseClasses;

namespace ScrappyDB.Membership
{
    public sealed class SdbMembershipProvider : MembershipProvider
    {
        private static readonly Logger Log = LogManager.GetCurrentClassLogger();

        private string _applicationName;
        private MembershipCommon _common;
        private Db _db;
        private bool _enable;
        private bool _enablePasswordReset;
        private MachineKeySection _machineKey;
        private int _maxInvalidPasswordAttempts;
        private int _minRequiredNonAlphanumericCharacters;
        private int _minRequiredPasswordLength;
        private int _passwordAttemptWindow;
        private MembershipPasswordFormat _passwordFormat;
        private string _passwordStrengthRegularExpression;
        private bool _requiresQuestionAndAnswer;
        private bool _requiresUniqueEmail;
        private bool _writeExceptionsToEventLog;

        public override string ApplicationName
        {
            get { return _applicationName; }
            set { _applicationName = value; }
        }

        public override bool EnablePasswordReset
        {
            get { return _enablePasswordReset; }
        }

        public override bool EnablePasswordRetrieval
        {
            get { return _enable; }
        }

        public override int MaxInvalidPasswordAttempts
        {
            get { return _maxInvalidPasswordAttempts; }
        }

        public override int MinRequiredNonAlphanumericCharacters
        {
            get { return _minRequiredNonAlphanumericCharacters; }
        }

        public override int MinRequiredPasswordLength
        {
            get { return _minRequiredPasswordLength; }
        }

        public override int PasswordAttemptWindow
        {
            get { return _passwordAttemptWindow; }
        }

        public override MembershipPasswordFormat PasswordFormat
        {
            get { return _passwordFormat; }
        }

        public override string PasswordStrengthRegularExpression
        {
            get { return _passwordStrengthRegularExpression; }
        }

        public override bool RequiresQuestionAndAnswer
        {
            get { return _requiresQuestionAndAnswer; }
        }

        public override bool RequiresUniqueEmail
        {
            get { return _requiresUniqueEmail; }
        }

        public bool WriteExceptionsToEventLog
        {
            get { return _writeExceptionsToEventLog; }
            set { _writeExceptionsToEventLog = value; }
        }

        /// <summary>
        /// MembershipProvider.ChangePassword
        /// </summary>
        /// <param name="username"></param>
        /// <param name="oldPwd"></param>
        /// <param name="newPwd"></param>
        /// <returns></returns>
        public override bool ChangePassword(string username, string oldPwd, string newPwd)
        {
            User user = _common.GetUserByUsernameOrEmail(username);
            if (user == null)
                throw new Exception(string.Format("User: {0} not found", username));

            if (!ValidateUser(username, oldPwd))
                return false;


            var args =
                new ValidatePasswordEventArgs(username, newPwd, true);

            OnValidatingPassword(args);

            if (args.Cancel)
                if (args.FailureInformation != null)
                    throw args.FailureInformation;
                else
                    throw new MembershipPasswordException(
                        "Change password canceled due to new password validation failure.");

            user.Password = EncodePassword(newPwd);
            user.LastPasswordChangedDate = DateTime.Now.ToUniversalTime();
            user.ApplicationName = _applicationName;

            _db.SaveChanges(user);

            return true;
        }


        //
        // MembershipProvider.ChangePasswordQuestionAndAnswer
        //

        public override bool ChangePasswordQuestionAndAnswer(string username,
                                                             string password,
                                                             string newPwdQuestion,
                                                             string newPwdAnswer)
        {
            throw new NotImplementedException();
        }


        //
        // MembershipProvider.CreateUser
        //

        public override MembershipUser CreateUser(string username,
                                                  string password,
                                                  string email,
                                                  string passwordQuestion,
                                                  string passwordAnswer,
                                                  bool isApproved,
                                                  object providerUserKey,
                                                  out MembershipCreateStatus status)
        {
            var args = new ValidatePasswordEventArgs(username, password, true);

            OnValidatingPassword(args);

            if (args.Cancel)
            {
                status = MembershipCreateStatus.InvalidPassword;
                return null;
            }


            if (RequiresUniqueEmail && GetUserNameByEmail(email) != "")
            {
                status = MembershipCreateStatus.DuplicateEmail;
                return null;
            }

            MembershipUser u = GetUser(username, false);

            if (u == null)
            {
                DateTime createDate = DateTime.Now.ToUniversalTime();

                if (providerUserKey == null)
                {
                    providerUserKey = Guid.NewGuid();
                }
                else
                {
                    if (!(providerUserKey is Guid))
                    {
                        status = MembershipCreateStatus.InvalidProviderUserKey;
                        return null;
                    }
                }

                SdbCollection<User> users = _db.Query<User>(string.Format(" UserName = '{0}' ", username), true);

                if (users.Count != 0)
                {
                    status = MembershipCreateStatus.UserRejected;

                    //TODO: log the exception instead if appropriate
                    throw new Exception("User: " + username + " already exists");
                }

                var user = new User
                               {
                                   UserId = (Guid) providerUserKey,
                                   UserName = username,
                                   Password = EncodePassword(password),
                                   Email = email,
                                   PasswordQuestion = passwordQuestion,
                                   PasswordAnswer = EncodePassword(passwordAnswer),
                                   IsApproved = isApproved,
                                   Comment = "",
                                   CreationDate = createDate,
                                   LastPasswordChangedDate = createDate,
                                   LastActivityDate = createDate,
                                   ApplicationName = _applicationName,
                                   IsLockedOut = false,
                                   LastLockedOutDate = createDate,
                                   FailedPasswordAttemptCount = 0,
                                   FailedPasswordAttemptWindowStart = createDate,
                                   FailedPasswordAnswerAttemptCount = 0,
                                   FailedPasswordAnswerAttemptWindowStart = createDate
                               };

                _db.SaveChanges(user);

                status = MembershipCreateStatus.Success;
                return GetUser(providerUserKey, false);
            }

            status = MembershipCreateStatus.DuplicateUserName;
            return null;
        }


        //
        // MembershipProvider.DeleteUser
        //

        public override bool DeleteUser(string username, bool deleteAllRelatedData)
        {
            //TODO: implement deleteAllRelatedData logic
            if (deleteAllRelatedData)
                throw new NotImplementedException();

            SdbCollection<User> users = _db.Query<User>(string.Format(" UserName = '{0}' ", username), true);

            //TODO: log the exception instead if appropriate
            if (users == null || users.Count == 0)
            {
                Log.Warn("WARNING: User: '" + username + "' not valid");
                return false;
            }

            foreach (User user1 in users)
            {
                //remove related UserInRoleRecords
                SdbCollection<UserInRole> usersInRoles =
                    _db.Query<UserInRole>(string.Format(" UserId = '{0}' ", user1.UserId), true);

                //TODO: IF(deleteAllRelatedData)
                foreach (UserInRole userInRole in usersInRoles)
                {
                    _db.Delete(userInRole);
                }

                //TODO: need to delete related user profile data here too!

                _db.Delete(user1);
            }


            return true;
        }

        public override MembershipUserCollection FindUsersByEmail(string emailToMatch, int pageIndex, int pageSize,
                                                                  out int totalRecords)
        {
            throw new NotImplementedException();
        }

        public override MembershipUserCollection FindUsersByName(string usernameToMatch, int pageIndex, int pageSize,
                                                                 out int totalRecords)
        {
            throw new NotImplementedException();
        }


        //
        // MembershipProvider.GetAllUsers
        //

        public override MembershipUserCollection GetAllUsers(int pageIndex, int pageSize, out int totalRecords)
        {
            var membershipUsers = new MembershipUserCollection();

            string count = _db.QueryValue(
                "select count(*) from User where ApplicationName = '" + ApplicationName + "' ", true);

            if (count == null || int.Parse(count) == 0)
            {
                totalRecords = 0;
                return membershipUsers;
            }
            totalRecords = int.Parse(count);

            string sql = string.Format(" ApplicationName = '{0}' and UserName is not null order by UserName Asc",
                                       ApplicationName);

            SdbCollection<User> users = _db.Query<User>(sql, true);


            //TODO: implement paging
            foreach (User user in users)
            {
                MembershipUser u = LoadMembershipUserFromUser(user);
                membershipUsers.Add(u);
            }

            return membershipUsers;
        }


        //
        // MembershipProvider.GetNumberOfUsersOnline
        //

        public override int GetNumberOfUsersOnline()
        {
            throw new NotImplementedException();
        }


        //
        // MembershipProvider.GetPassword
        //

        public override string GetPassword(string username, string answer)
        {
            //HACK: to get around AutoMapper calling this method...
            return null;

            //if (!EnablePasswordRetrieval)
            //{
            //    throw new ProviderException("Password Retrieval Not Enabled.");
            //}

            //if (PasswordFormat == MembershipPasswordFormat.Hashed)
            //{
            //    throw new ProviderException("Cannot retrieve Hashed passwords.");
            //}

            //User user = _common.GetUserByUsernameOrEmail(username);

            //if (user == null)
            //    throw new MembershipPasswordException("The supplied user name is not found.");

            //string password = "";
            //string passwordAnswer = "";

            //if (user.IsLockedOut)
            //    throw new MembershipPasswordException("The supplied user is locked out.");

            //password = user.Password;
            //passwordAnswer = user.PasswordAnswer;


            //if (RequiresQuestionAndAnswer && !CheckPassword(answer, passwordAnswer))
            //{
            //    UpdateFailureCount(username, "passwordAnswer");

            //    throw new MembershipPasswordException("Incorrect password answer.");
            //}


            //if (PasswordFormat == MembershipPasswordFormat.Encrypted)
            //{
            //    password = UnEncodePassword(password);
            //}

            //return password;
        }


        //
        // MembershipProvider.GetUser(string, bool)
        //

        public override MembershipUser GetUser(string username, bool userIsOnline)
        {
            User user = _common.GetUserByUsernameOrEmail(username);

            if (user == null)
                return null;

            MembershipUser result = LoadMembershipUserFromUser(user);

            //update last activity if user is online (we are only going to do this if the user hasn't been active in the past 10 minutes to cut down on the database traffic)
            if (userIsOnline && user.LastActivityDate.AddMinutes(10) < DateTime.Now.ToUniversalTime())
            {
                user.LastActivityDate = DateTime.Now.ToUniversalTime();
                _db.SaveChanges(user);
            }

            return result;
        }


        //
        // MembershipProvider.GetUser(object, bool)
        //

        public override MembershipUser GetUser(object providerUserKey, bool userIsOnline)
        {
            //TODO: exception handling
            var user = _db.Find<User>(providerUserKey.ToString());

            if (user == null)
                throw new SdbMembershipProviderException("User not found for id: '" + providerUserKey +
                                                         "'");

            MembershipUser result = LoadMembershipUserFromUser(user);

            return result;
        }

        //
        // MembershipProvider.GetUserNameByEmail
        //

        public override string GetUserNameByEmail(string email)
        {
            return _common.GetUserName(email);
        }

        public override void Initialize(string name, NameValueCollection config)
        {
            //
            // Initialize values from web.config.
            //

            if (config == null)
                throw new ArgumentNullException("config");

            if (string.IsNullOrEmpty(name))
                name = "SdbMembershipProvider";

            if (String.IsNullOrEmpty(config["description"]))
            {
                config.Remove("description");
                config.Add("description", "ScrappyDB Membership provider");
            }

            // Initialize the abstract base class.
            base.Initialize(name, config);

            _applicationName = GetConfigValue(config["applicationName"],
                                              HostingEnvironment.ApplicationVirtualPath);
            _maxInvalidPasswordAttempts = Convert.ToInt32(GetConfigValue(config["maxInvalidPasswordAttempts"], "5"));
            _passwordAttemptWindow = Convert.ToInt32(GetConfigValue(config["passwordAttemptWindow"], "10"));
            _minRequiredNonAlphanumericCharacters =
                Convert.ToInt32(GetConfigValue(config["minRequiredNonAlphanumericCharacters"], "1"));
            _minRequiredPasswordLength = Convert.ToInt32(GetConfigValue(config["minRequiredPasswordLength"], "7"));
            _passwordStrengthRegularExpression =
                Convert.ToString(GetConfigValue(config["passwordStrengthRegularExpression"], ""));
            _enablePasswordReset = Convert.ToBoolean(GetConfigValue(config["enablePasswordReset"], "true"));
            _enable = Convert.ToBoolean(GetConfigValue(config["enablePasswordRetrieval"], "true"));
            _requiresQuestionAndAnswer = Convert.ToBoolean(GetConfigValue(config["requiresQuestionAndAnswer"], "false"));
            _requiresUniqueEmail = Convert.ToBoolean(GetConfigValue(config["requiresUniqueEmail"], "true"));
            _writeExceptionsToEventLog = Convert.ToBoolean(GetConfigValue(config["writeExceptionsToEventLog"], "true"));

            string tempFormat = config["passwordFormat"] ?? "Hashed";

            switch (tempFormat)
            {
                case "Hashed":
                    _passwordFormat = MembershipPasswordFormat.Hashed;
                    break;
                case "Encrypted":
                    _passwordFormat = MembershipPasswordFormat.Encrypted;
                    break;
                case "Clear":
                    _passwordFormat = MembershipPasswordFormat.Clear;
                    break;
                default:
                    throw new ProviderException("Password format not supported.");
            }

            //
            // Initialize SimpleDB.
            //
            Cache cache = null;
            if (HttpContext.Current != null)
                cache = HttpContext.Current.Cache;
            _db = new Db(cache);

            _common = new MembershipCommon(_db);

            // Get encryption and decryption key information from the configuration.
            Configuration cfg =
                WebConfigurationManager.OpenWebConfiguration(HostingEnvironment.ApplicationVirtualPath);
            _machineKey = (MachineKeySection) cfg.GetSection("system.web/machineKey");

            if (_machineKey.ValidationKey.Contains("AutoGenerate"))
                if (PasswordFormat != MembershipPasswordFormat.Clear)
                    throw new ProviderException("Hashed or Encrypted passwords " +
                                                "are not supported with auto-generated keys.");
        }

        //
        // MembershipProvider.ResetPassword
        //

        public override string ResetPassword(string username, string answer)
        {
            throw new NotImplementedException();
        }

        public override bool UnlockUser(string username)
        {
            throw new NotImplementedException();
        }


        //
        // MembershipProvider.UpdateUser
        //

        public override void UpdateUser(MembershipUser membershipUser)
        {
            if (membershipUser.ProviderUserKey != null)
            {
                var user = _db.Find<User>(membershipUser.ProviderUserKey.ToString());

                if (user == null)
                    throw new SdbMembershipProviderException("User not found");

                user.UserName = membershipUser.UserName;
                user.Email = membershipUser.Email;
                user.IsApproved = membershipUser.IsApproved;
                user.Comment = membershipUser.Comment;

                _db.SaveChanges(user);
            }
        }


        //
        // MembershipProvider.ValidateUser
        //

        public override bool ValidateUser(string username, string password)
        {
            bool isValid = false;

            User user = _common.GetUserByUsernameOrEmail(username);

            if (user != null)
            {
                bool isApproved = user.IsApproved;
                string dbpassword = user.Password;


                if (CheckPassword(password, dbpassword))
                {
                    if (isApproved)
                    {
                        isValid = true;

                        //only update the database if this is the first activity in over 10 minute
                        if (user.LastLoginDate.AddMinutes(10) < DateTime.Now.ToUniversalTime()
                            || user.LastActivityDate.AddMinutes(10) < DateTime.Now.ToUniversalTime())
                        {
                            user.LastLoginDate = DateTime.Now.ToUniversalTime();
                            user.LastActivityDate = DateTime.Now.ToUniversalTime();
                            try
                            {
                                _db.SaveChanges(user);
                            }
                            catch (SdbExceptions.SdbSecurityException)
                            {
                                //swallow this... we DON'T want to allow login to fail in a read-only situation
                            }
                        }
                    }
                    else
                    {
                        Log.Warn("LOGIN FAILURE: account not activated user:{0}", username);
                    }
                }
                else
                {
                    Log.Warn("LOGIN FAILURE: bad password - user:{0} password:{1}", username, password);
                    UpdateFailureCount(username, "password");
                }
            }
            else
            {
                Log.Warn("LOGIN FAILURE: non existent user:{0}", username);
            }

            return isValid;
        }

        //
        // CheckPassword
        //   Compares password values based on the MembershipPasswordFormat.
        //

        private bool CheckPassword(string password, string dbpassword)
        {
            string pass1 = password;
            string pass2 = dbpassword;

            switch (PasswordFormat)
            {
                case MembershipPasswordFormat.Encrypted:
                    pass2 = UnEncodePassword(dbpassword);
                    break;
                case MembershipPasswordFormat.Hashed:
                    pass1 = EncodePassword(password);
                    break;
                default:
                    break;
            }

            if (pass1 == pass2)
            {
                return true;
            }

            return false;
        }


        //
        // EncodePassword
        //   Encrypts, Hashes, or leaves the password clear based on the PasswordFormat.
        //

        private string EncodePassword(string password)
        {
            string encodedPassword = password;

            switch (PasswordFormat)
            {
                case MembershipPasswordFormat.Clear:
                    break;
                case MembershipPasswordFormat.Encrypted:
                    encodedPassword =
                        Convert.ToBase64String(EncryptPassword(Encoding.Unicode.GetBytes(password)));
                    break;
                case MembershipPasswordFormat.Hashed:
                    var hash = new HMACSHA1
                                   {
                                       Key = HexToByte(_machineKey.ValidationKey)
                                   };
                    encodedPassword = Convert.ToBase64String(hash.ComputeHash(Encoding.Unicode.GetBytes(password)));
                    break;
                default:
                    throw new ProviderException("Unsupported password format.");
            }

            return encodedPassword;
        }

        private static string GetConfigValue(string configValue, string defaultValue)
        {
            if (String.IsNullOrEmpty(configValue))
                return defaultValue;

            return configValue;
        }


        //
        // HexToByte
        //   Converts a hexadecimal string to a byte array. Used to convert encryption
        // key values from the configuration.
        //

        private static byte[] HexToByte(string hexString)
        {
            var returnBytes = new byte[hexString.Length/2];
            for (int i = 0; i < returnBytes.Length; i++)
                returnBytes[i] = Convert.ToByte(hexString.Substring(i*2, 2), 16);
            return returnBytes;
        }

        private MembershipUser LoadMembershipUserFromUser(User user)
        {
            var result = new MembershipUser(Name,
                                            user.UserName,
                                            user.UserId,
                                            user.Email,
                                            user.PasswordQuestion,
                                            user.Comment,
                                            user.IsApproved,
                                            user.IsLockedOut,
                                            user.CreationDate,
                                            user.LastLoginDate,
                                            user.LastActivityDate,
                                            user.LastPasswordChangedDate,
                                            user.LastLockedOutDate);
            return result;
        }

        private string UnEncodePassword(string encodedPassword)
        {
            string password = encodedPassword;

            switch (PasswordFormat)
            {
                case MembershipPasswordFormat.Clear:
                    break;
                case MembershipPasswordFormat.Encrypted:
                    password =
                        Encoding.Unicode.GetString(DecryptPassword(Convert.FromBase64String(password)));
                    break;
                case MembershipPasswordFormat.Hashed:
                    throw new ProviderException("Cannot unencode a hashed password.");
                default:
                    throw new ProviderException("Unsupported password format.");
            }

            return password;
        }

        private void UpdateFailureCount(string username, string failureType)
        {
            User user = _common.GetUserByUsernameOrEmail(username);
            if (user == null)
                throw new Exception(string.Format("User: {0} not found", username));

            var windowStart = new DateTime();
            int failureCount = 0;


            if (failureType == "password")
            {
                failureCount = user.FailedPasswordAttemptCount;
                windowStart = user.FailedPasswordAttemptWindowStart;
            }

            if (failureType == "passwordAnswer")
            {
                failureCount = user.FailedPasswordAnswerAttemptCount;
                windowStart = user.FailedPasswordAnswerAttemptWindowStart;
            }

            failureCount++;


            DateTime windowEnd = windowStart.AddMinutes(PasswordAttemptWindow);

            if (failureCount == 0 || DateTime.Now.ToUniversalTime() > windowEnd)
            {
                // First password failure or outside of PasswordAttemptWindow. 
                // Start a new password failure count from 1 and a new window starting now.

                if (failureType == "password")
                {
                    user.FailedPasswordAttemptCount = failureCount;
                    user.FailedPasswordAttemptWindowStart = windowStart;
                }

                if (failureType == "passwordAnswer")
                {
                    user.FailedPasswordAnswerAttemptCount = failureCount;
                    user.FailedPasswordAnswerAttemptWindowStart = windowStart;
                }
            }
            else
            {
                if (failureCount++ >= MaxInvalidPasswordAttempts)
                {
                    // Password attempts have exceeded the failure threshold. Lock out
                    // the user.

                    user.IsLockedOut = true;
                    user.LastLockedOutDate = DateTime.Now.ToUniversalTime();
                }
                else
                {
                    // Password attempts have not exceeded the failure threshold. Update
                    // the failure counts. Leave the window the same.

                    if (failureType == "password")
                    {
                        user.FailedPasswordAttemptCount = failureCount;
                        user.FailedPasswordAttemptWindowStart = windowStart;
                    }

                    if (failureType == "passwordAnswer")
                    {
                        user.FailedPasswordAnswerAttemptCount = failureCount;
                        user.FailedPasswordAnswerAttemptWindowStart = windowStart;
                    }
                }
            }
        }
    }
}